<?php

/**
 * Based on MySession class from
 * http://blog.perplexedlabs.com/2009/10/05/php-custom-session-handler/
 *
 */
namespace org\copperforest\oak\authentication;

use \org\copperforest\oak as oak;
use \org\copperforest\oak\sql as sql;
use \org\copperforest\authentication as cfa;

/**
 * Description of MySession
 *
 * @author agama
 */
//NO TENGO QUE UTILIZAR PARA NADA EL MECANISMO DE SESSIONES DE PHP, NI SIQUIERA PARA EL RECOLECTOR DE BASURA
//por linea de comandos puedo querer varias sessiones para actuar como distintos actores por lo cual no debe ser singleton
abstract class OAKSession implements cfa\SessionInterface, OAKSessionInterface, \Countable, \ArrayAccess, \Iterator
{

    private $session_name;
    private $session_id;

    private $path;
    private $domain;
    private $closed = false;
    
    private $expiration;
    private $cookie_expiration;
    
    private $csrf_token_prefix;
    private $allow_anonymous;
    
    protected $link;
    private $locks = array( );
    private $valid = false;
    
    protected $user_id = 0;
    private $groups = array();

    public function __construct( $session_number )
    {
               
        $this->path = $_SERVER[ 'CF_INI' ][ 'session.' . $session_number . '.realm.path' ];
        $this->domain = $_SERVER[ 'CF_INI' ][ 'session.' . $session_number . '.realm.domain' ];
        $this->expiration = $_SERVER[ 'CF_INI' ][ 'session.' . $session_number . '.expiration' ];
        $this->cookie_expiration = $_SERVER[ 'CF_INI' ][ 'session.' . $session_number . '.cookie_expiration' ];
        $this->session_name = md5(  $_SERVER[ 'CF_INI' ][ 'session.' . $session_number . '.oak.csrf_token_prefix' ] . 'SESSION_NAME' );
        $this->csrf_token_prefix = $_SERVER[ 'CF_INI' ][ 'session.' . $session_number . '.oak.csrf_token_prefix' ];
        $this->allow_anonymous = $_SERVER[ 'CF_INI' ][ 'session.' . $session_number . '.allow_anonymous' ];
        
        //la conexion de
        if ( $_SERVER[ 'CF_INI' ][ 'session.' . $session_number . '.oak.use_db_connection' ] ){
            $this->link = oak\Oak::pdo();
        }
        else{
            $this->link = new sql\PDO(
                $_SERVER[ 'CF_INI' ][ 'session.' . $session_number . '.pdo.connection_string' ],
                $_SERVER[ 'CF_INI' ][ 'session.' . $session_number . '.pdo.user' ],
                $_SERVER[ 'CF_INI' ][ 'session.' . $session_number . '.pdo.password' ],
                isset( $_SERVER[ 'CF_INI' ][ 'session.' . $session_number . '.pdo.options' ] )?$_SERVER[ 'CF_INI' ][ 'session.' . $session_number . '.pdo.options' ]:null
            );
            
            $this->link->setAttribute( \PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION );
        }
        //TENGO QUE TENER UNA API PARA OpenID

        if ( isset( $_COOKIE[ $this->session_name ] ) )
                $this->session_id = $_COOKIE[ $this->session_name ];
        else if ( isset( $_GET[ $this->session_name ] ) )
                $this->session_id = $_GET[ $this->session_name ];
       
        //El token de los formularios se puede calcular a partir de session_id y  actual md5(csrf_token_prefix.$this->session_id)
        //Un token está vivo durante 14 minutos, y se genera uno nuevo cada 7
        
        //OJO AL CAMBIAR DE SESSION ID VA A CAMBIAR EL TOKEN, SI SE PRODUCE ESTO EN MITAD DE UN FORMULARIO HABRIA UN FALSO POSITIVO
        if( 
            preg_match( '/^[a-z0-9]+$/', $this->session_id ) &&
            ( 
                $_SERVER[ 'REQUEST_METHOD' ] != 'POST' ||
                in_array( $_POST[ 'oak_csrf_token' ], array( $_SESSION [ 'OAK_CSRF_TOKEN' ], $_SESSION [ 'OAK_CSRF_OLD_TOKEN' ] ) ) 
            ) 
        ) {

            
            //Aqui debería extraer el data y el user, realmente no porque todavía no está bloqueada la session
            $result = $this->link->query( 'SELECT session_id, user_id FROM oak_session WHERE (session_id_old=' . $this->link->helper->quote( $this->session_id ) . ' OR session_id=' . $this->link->helper->quote( $this->session_id ) . ') AND user_agent=' . $this->link->helper->quote( substr( $_SERVER[ 'HTTP_USER_AGENT' ],
                                    0, 120 ) ) . ' AND last_activity > ( NOW() - INTERVAL '.$this->expiration.' SECOND )  ORDER BY last_activity DESC LIMIT 1' );//AND ip_address=\'' . self::getRealIP() . '\'  SOLO DEBERÍA HABER 1


            
            list( $sid, $user_id ) = $result->fetch( \PDO::FETCH_NUM ); //deberia obtener
            $result->closeCursor();
            
            //Por ahora el $data no me sirve ya que no está bloqueado y otra llamada puede estar editandolo
            
            if ( !empty( $sid ) ) {

                if ( $this->session_id != $sid ) {
                    $this->session_id = $sid;
                }
               
                //bloqueo el id más reciente
                $this->acquire( $this->session_id );
                
                //OBTENEMOS LOS DATOS CON EL ID BLOQUEADO
                $result = $this->link->query( 'SELECT user_data, UNIX_TIMESTAMP( last_refresh ), user_id FROM oak_session WHERE session_id=\'' . $this->session_id . '\' LIMIT 1' );//AND ip_address=\'' . self::getRealIP() . '\'  SOLO DEBERÍA HABER 1
                list( $data, $last_refresh, $user_id ) = $result->fetch( \PDO::FETCH_NUM );
                $result->closeCursor();
                
                if ( $last_refresh + 600 < $_SERVER[ 'REQUEST_TIME' ] ) { //Cada 10 minutos cambiamos el SID
                    $this->regenerate();
                }
                
                $this->user_id = $user_id;
                $this->user_data = unserialize( $data );

                //  $this->valid = ( count($this->user_data)>0 );
            }
            else{ //Session fixation
                $this->regenerate( true );
            }
        }
        else{
            $this->regenerate( true );
        }

        
        if( !empty( $this->user_id ) ){

            $result = $this->link->query( 'SELECT user_group, security_level FROM oak_user_group WHERE user_id=' . $this->user_id.' ORDER BY user_group, security_level DESC' );

            while( $row = $result->fetch( \PDO::FETCH_NUM ) ){

                if( !isset( $this->groups[ $row[ 0 ] ] ) ){
                    $this->groups[ $row[ 0 ] ] = array();
                }

                $this->groups[ $row[ 0 ] ][] = $row[ 1 ];
            }

            $result->closeCursor();
        }
    }
    
    public function getId()
    {
        return $this->session_id;
    }

    
    public function getUserId()
    {
        return $this->user_id;
    }
    
    public function getGroups()
    {
        return $this->groups;
    }
   
    
    public function tempDirectoryWasCreated()
    {
        return file_exists( CF_TEMP_PATH . $this->session_id );
    }
    
    public function createTempDirectory()
    {
        mkdir(  CF_TEMP_PATH . $this->session_id, 0777 );
        chmod(  CF_TEMP_PATH . $this->session_id, 0777 );
    }

    //cuando nos queramos autentificar deberemos llamar a regenerate
    public function regenerate( $emptydata = false )
    {
        
        if( $emptydata &&  mt_rand( 1, 100 ) == 1 ) { //Uno de cada 100 inicios de session recolecta
            $this->gc( $this->expiration );
        }
        
        $sid = $this->session_id; //mantenemos el valor de la anterior sesion para renombrar el directorio
        $last = 0;
        
        do{
            $this->session_id = generateUniqueId( 40 );
            
            if ( $this->link->helper->isLimitAvailable() )
                    $result = $this->link->query( 'SELECT 1 FROM oak_session WHERE session_id_old=\'' . $this->session_id . '\' OR session_id=\'' . $this->session_id . '\' LIMIT 1' );
            else
                    $result = $this->link->query( 'SELECT TOP(1) 1 FROM oak_session WHERE session_id_old=\'' . $this->session_id . '\' OR session_id=\'' . $this->session_id . '\'' );
            
            $last = $result->fetchAll( \PDO::FETCH_COLUMN, 0 );
            $result->closeCursor();
        }
        while( !empty( $last ) );
        
        $this->acquire( $this->session_id );
        
        if( $emptydata ){
            
            $this->user_data = '';

            $this->user_id = $this->evalAuthReply();

            if ( empty( $this->user_id ) && !$this->allow_anonymous ){
                $this->sendAuthRequest();
            }

            
            $this->link->exec(
                    'INSERT INTO oak_session ( session_id, ip_address, user_agent, last_refresh, last_activity, user_id ) VALUES ( \'' . $this->session_id . '\', \'' . self::getRealIP() . '\', ' . $this->link->quote( substr( $_SERVER[ 'HTTP_USER_AGENT' ],
                                    0, 120 ) ) . ', NOW(), NOW(), ' . $this->user_id . ' )'
            );

        }
        else{
            //Actualizamos SID
            $this->link->exec( 'UPDATE oak_session SET session_id_old=session_id, session_id=\'' . $this->session_id . '\', last_refresh=NOW() WHERE session_id=\'' . $sid . '\'' ); //session_id_old=\''.$this->session_id.'\' OR
             
            $_SESSION [ 'OAK_CSRF_OLD_TOKEN' ] = $_SESSION [ 'OAK_CSRF_TOKEN' ]; 
                    
            //TENEMOS QUE RENOMBRAR EL DIRECTORIO
            if( file_exists( CF_TEMP_PATH . $sid ) )
                rename( CF_TEMP_PATH . $sid, CF_TEMP_PATH . $this->session_id );
        }
        
        $_SESSION [ 'OAK_CSRF_TOKEN' ] = $this->csrf_token = md5( $this->csrf_token_prefix . $_SERVER[ 'REQUEST_TIME' ] . $this->session_id );
        
    }

    public function getCsrfToken()
    {
        return $_SESSION[ 'OAK_CSRF_TOKEN' ];
    }
  
//OJO ESTO TENGO QUE EJECUTARLO ANTES DE ENVIAR NADA DE VUELTA
    public function writeClose()
    {
        if( !$this->closed ){
            
                
            if ( !isset( $_COOKIE[ $this->session_name ] ) || $_COOKIE[ $this->session_name ] != $this->session_id ){
                    setcookie( $this->session_name, $this->session_id,
                        ($this->cookie_expiration > 0)?($_SERVER[ 'REQUEST_TIME' ] + $this->cookie_expiration):0, $this->path,
                        $this->domain, 1 );
            }

            //if($this->modified) //esto me puede ahorrar algunos UPDATES...
            //OJO PORQUE puedo estar guardando punteros y los valores de los datos apuntados por esos punteros pueden haber sido modificados
            
            $this->link->exec(
                    'UPDATE oak_session SET user_data=' . $this->link->helper->quote( serialize( $this->user_data ) ) . ', last_activity=NOW() WHERE session_id=\'' . $this->session_id . '\''
            );
            

            foreach ($this->locks as $sid => $bool ) {
                $this->link->releaseLock( 'oak_sesslock_' . $sid );
            }
            
            $this->close = true;
        }
    }
    
    public function gc()
    {
        $lifetime = '\'' . date( 'Y-m-d H:i:s', $_SERVER[ 'REQUEST_TIME' ] - $this->expiration )  . '\'' ;
        
        
        $result = $this->link->query( 'SELECT session_id FROM `oak_session` WHERE `last_activity` <' . $lifetime );
        $session_ids = $result->fetchColumn();
        $result->closeCursor();
        
        foreach( $session_ids as $sid ){
            if( $this->acquire( $sid, false ) ){ //Nos aseguramos que no estén bloqueados

                $this->link->exec( 'INSERT INTO oak_session_log (  ip_address, user_agent, first_activity, last_activity, user_id ) SELECT  ip_address, user_agent, first_activity, last_activity, user_id  FROM oak_session WHERE session_id=' .  $sid );
                $this->link->exec( 'DELETE FROM oak_session WHERE session_id=\'' .$sid . '\'' );

                if( file_exists( CF_TEMP_PATH . $sid ) ) {
                    rmtree( CF_TEMP_PATH . $sid );
                }

                $this->release( $sid );
            }
        }
         
    }

    private function acquire( $sid , $wait = true)
    {

        $time = ini_get( 'max_execution_time' ); //esto me asegura que el script va a morir antes que el bloqueo
        if ( empty( $time ) ) $time = '600';


        $this->locks[ $sid ] = true; //debería ponerlo como bloqueada aunque no lo esté

        $step = 1;
        while ( !$this->link->getLock( 'oak_sesslock_' . $sid,
                $time ) ) {
            
            if( $wait ){
                sleep( mt_rand( 1, $step ) );
                $step++;
            }
            else{
                return false;
            }
        }

        return true;
    }

    private function release( $sid )
    {
        if ( $this->link->releaseLock( 'oak_sesslock_' . $sid ) ) {
            unset( $this->locks[ $sid ] );
            return true;
        }

        return false;
    }

    //coutable Method
    public function count()
    {
        return count( $this->user_data );
    }

    //Serializable Methods
    public function rewind()
    {
        $this->valid = ( count( $this->user_data ) > 0 );
        rewind( $this->user_data );
    }

    public function key()
    {
        return key( $this->user_data );
    }

    public function current()
    {
        return current( $this->user_data );
    }

    public function next()
    {
        $this->valid = ( next( $this->user_data ) !== false );
    }

    public function valid()
    {
        return $this->valid;
    }

    //ArrayAccess
    public function offsetSet( $k, $v )
    {
        $this->user_data[ $k ] = $v;
    }

    public function offsetGet( $k )
    {
        return $this->user_data[ $k ];
    }

    public function offsetUnset( $k )
    {
        unset( $this->user_data[ $k ] );
    }

    public function offsetExists( $k )
    {
        return isset( $this->user_data[ $k ] );
    }


    abstract public function getDefaultRelativeReference();
    //TIENE QUE DEVOLVER EL user_id
    abstract public function evalAuthReply();

    abstract public function sendAuthRequest();

    static function build()
    {
        //puede tener dos identificadores que vayan caducando cada 14 mins
        //pero que vayan cambiado cada 7
        $connections = array( );

        foreach ( $_SERVER[ 'CF_INI' ] as $key => $value ) {

            if ( substr( $key, 0, 8 ) == 'session.' ) {

                $matches = array( );

                if ( preg_match( '/^session\.(\d+).*$/', $key, $matches ) ) {
                    $num = $matches[ 1 ];

                    if ( !isset( $connections[ $num ] ) ) {
                            $connections[ $num ] = array( );
                    }

                    if ( substr( $key, -22 ) == '.oak.use_db_connection' ) {

                        $connections[ $num ][ 'connection_string' ] = $_SERVER[ 'CF_INI' ][ 'oak.pdo.connection_string' ];
                        $connections[ $num ][ 'user' ] = $_SERVER[ 'CF_INI' ][ 'oak.pdo.user' ];
                        $connections[ $num ][ 'password' ] = $_SERVER[ 'CF_INI' ][ 'oak.pdo.password' ];
                        $connections[ $num ][ 'options' ] = isset($_SERVER[ 'CF_INI' ][ 'oak.pdo.options' ])?$_SERVER[ 'CF_INI' ][ 'oak.pdo.options' ]:null;
                        
                    }
                    else if ( substr( $key, -22 ) == '.pdo.connection_string' ) {
                            $connections[ $num ][ 'connection_string' ] = ( int ) $value;
                    }
                    else if ( substr( $key, -9 ) == '.pdo.user' ) {
                            $connections[ $num ][ 'user' ] = ( int ) $value;
                    }
                    else if ( substr( $key, -9 ) == '.pdo.password' ) {
                            $connections[ $num ][ 'pass' ] = ( int ) $value;
                    }
                    else if ( substr( $key, -12 ) == '.pdo.options' ) {
                            $connections[ $num ][ 'options' ] = ( int ) $value;
                    }
                }
            }
        }

        $num_connections = count( $connections );

        for ( $i = 0; $i < $num_connections; $i++ ) {

            $yet_connected = false;

            for ( $j = 0; $j < $i; $j++ ) {

                if ( $connections[ $j ][ 'connection_string' ] == $connections[ $i ][ 'connection_string' ] ) {
                    $yet_connected = true;
                    break;
                }
            }

            if ( !$yet_connected ) {

                $link = new sql\PDO(
                        $connections[ $i ][ 'connection_string' ],
                        $connections[ $i ][ 'user' ],
                        $connections[ $i ][ 'password' ],
                        $connections[ $i ][ 'options' ]
                );


                $sql = 'CREATE TABLE IF NOT EXISTS  `oak_session` (
                session_id binary (40) DEFAULT \'\' NOT NULL,
                session_id_old binary (40) DEFAULT \'\' NOT NULL,
                ip_address varchar (45) DEFAULT \'0.0.0.0\' NOT NULL,
                user_agent varbinary (120) NOT NULL,
                user_id INT (15) NOT NULL,
                fisrt_activity timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
                last_activity timestamp NOT NULL DEFAULT \'0000-00-00 00:00:00\',
                last_refresh timestamp NOT NULL DEFAULT \'0000-00-00 00:00:00\',
                user_data varchar(8000) NULL,
                valid binary(1) DEFAULT \'Y\',
                PRIMARY KEY ( session_id ),
                KEY `last_activity_idx` ( `last_activity` ) ) ENGINE='.(in_array('ndbcluster', oak\Oak::pdo()->getStorageEngines())?'ndbcluster':'innodb').';';

                $link->exec( $sql );

                $sql = 'CREATE TABLE IF NOT EXISTS `oak_session_log` (
                user_id INT (15) NOT NULL,
                ip_address varchar(45) DEFAULT \'0.0.0.0\' NOT NULL,
                user_agent varbinary(120) NOT NULL,
                fisrt_activity timestamp NOT NULL DEFAULT \'0000-00-00 00:00:00\',
                last_activity timestamp NOT NULL DEFAULT \'0000-00-00 00:00:00\',
                KEY `last_activity_idx` ( `last_activity` ) ) ENGINE='.(in_array('ndbcluster', oak\Oak::pdo()->getStorageEngines())?'ndbcluster':'innodb').';';

                $link->exec( $sql );

                $sql = 'CREATE TABLE IF NOT EXISTS `oak_user_group` (
                user_id INT (15) NOT NULL,
                user_group varbinary(120) NOT NULL,
                security_level INT (15) NOT NULL,
                KEY `user_id_idx` ( `user_id` ) ) ENGINE='.(in_array('ndbcluster', oak\Oak::pdo()->getStorageEngines())?'ndbcluster':'innodb').';';

                $link->exec( $sql );
            }
        }
    }

    final static function getRealIP()
    {
//from https://www.phpbb.com/

        if ( isset( $_SERVER[ 'HTTP_X_FORWARDED_FOR' ] ) && $_SERVER[ 'HTTP_X_FORWARDED_FOR' ] != '' ) {
            $client_ip =
                    (!empty( $_SERVER[ 'REMOTE_ADDR' ] ) ) ?
                    $_SERVER[ 'REMOTE_ADDR' ] :
                    ( (!empty( $_ENV[ 'REMOTE_ADDR' ] ) ) ?
                            $_ENV[ 'REMOTE_ADDR' ] :
                            "unknown" );

            // los proxys van añadiendo al final de esta cabecera
            // las direcciones ip que van "ocultando". Para localizar la ip real
            // del usuario se comienza a mirar por el principio hasta encontrar
            // una dirección ip que no sea del rango privado. En caso de no
            // encontrarse ninguna se toma como valor el REMOTE_ADDR

            $entries = split( '[, ]', $_SERVER[ 'HTTP_X_FORWARDED_FOR' ] );

            reset( $entries );
            while ( list(, $entry) = each( $entries ) ) {
                $entry = trim( $entry );
                if ( preg_match( "/^([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)/", $entry,
                                $ip_list ) ) {
                    $private_ip = array(
                        '/^0\./',
                        '/^127\.0\.0\.1/',
                        '/^192\.168\..*/',
                        '/^172\.((1[6-9])|(2[0-9])|(3[0-1]))\..*/',
                        '/^10\..*/' );

                    $found_ip = preg_replace( $private_ip, $client_ip,
                            $ip_list[ 1 ] );

                    if ( $client_ip != $found_ip ) {
                        $client_ip = $found_ip;
                        break;
                    }
                }
            }
        } else {
            $client_ip =
                    (!empty( $_SERVER[ 'REMOTE_ADDR' ] ) ) ?
                    $_SERVER[ 'REMOTE_ADDR' ] :
                    ( (!empty( $_ENV[ 'REMOTE_ADDR' ] ) ) ?
                            $_ENV[ 'REMOTE_ADDR' ] :
                            "unknown" );
        }

        return $client_ip;
    }

    function get_real_ip()
    {
        $proxy_headers = array(
            'CLIENT_IP',
            'FORWARDED',
            'FORWARDED_FOR',
            'FORWARDED_FOR_IP',
            'HTTP_CLIENT_IP',
            'HTTP_FORWARDED',
            'HTTP_FORWARDED_FOR',
            'HTTP_FORWARDED_FOR_IP',
            'HTTP_PC_REMOTE_ADDR',
            'HTTP_PROXY_CONNECTION',
            'HTTP_VIA',
            'HTTP_X_FORWARDED',
            'HTTP_X_FORWARDED_FOR',
            'HTTP_X_FORWARDED_FOR_IP',
            'HTTP_X_IMFORWARDS',
            'HTTP_XROXY_CONNECTION',
            'VIA',
            'X_FORWARDED',
            'X_FORWARDED_FOR'
        );

        foreach ( $proxy_headers as $proxy_header ) {
            if ( isset( $_SERVER[ $proxy_header ] ) && preg_match( "/^([1-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])(\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])){3}$/",
                            $_SERVER[ $proxy_header ] ) ) /* HEADER ist gesetzt und dies ist eine gültige IP */ {
                return $_SERVER[ $proxy_header ];
            } else if ( stristr( ',', $_SERVER[ $proxy_header ] ) !== FALSE ) /* Behandle mehrere IPs in einer Anfrage(z.B.: X-Forwarded-For: client1, proxy1, proxy2) */ {
                $proxy_header_temp = trim( array_shift( explode( ',',
                                        $_SERVER[ $proxy_header ] ) ) ); /* Teile in einzelne IPs, gib die letzte zurück und entferne Leerzeichen */

                if ( ($pos_temp = stripos( $proxy_header_temp, ':' )) !== FALSE )
                        $proxy_header_temp = substr( $proxy_header_temp, 0,
                            $pos_temp ); /* Entferne den Port */

                if ( preg_match( '/^([1-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])(\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])){3}$/',
                                $proxy_header_temp ) )
                        return $proxy_header_temp;
            }
        }

        return $_SERVER[ 'REMOTE_ADDR' ];
    }

}